Skip to content

feat(storage): reclaim the prepared-request captures already on disk - #4738

Merged
Astro-Han merged 9 commits into
apache:mainfrom
Astro-Han:feat/reclaim-retired-provider-request-captures
Sep 4, 2026
Merged

feat(storage): reclaim the prepared-request captures already on disk#4738
Astro-Han merged 9 commits into
apache:mainfrom
Astro-Han:feat/reclaim-retired-provider-request-captures

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

#4722 stops producing prepared-request captures. This reclaims the ones already on disk. On my own 814 MB installation they are 772.7 MB — 87% of the Artifact population, and 99.4% of their bytes are re-serialized duplicates of messages already in the ledger.

A bounded background sweep drains them through the same purge-intent path a Session delete uses, then stops. It reclaims the files and the Artifact rows naming them — nothing else. The per-segment detail #4722 stopped writing sits in the AgentRun ledger, which is append-only by design, so those rows stay where they are and only new calls are cheaper.

#4722 has landed, so this now stands on its own: 7 commits on current main.

Refs #4037, #4704.

🔀 What this changes about deletion

flowchart TB
    subgraph BEFORE["until now"]
        direction LR
        P1["purgeSessionArtifacts"] --> B1["Artifact bytes"]
        P1 --> E1["events naming them"]
    end
    subgraph AFTER["this sweep"]
        direction LR
        P2["purgeRetiredCaptures"] --> B2["capture bytes"]
        E2["attempts naming them<br/>append-only, cannot be retracted"]
    end
    BEFORE -.-> R["⇒ a capture reference<br/>must tolerate a missing referent"]
    AFTER --> R

    classDef gone fill:#fcebeb,stroke:#e24b4a,color:#a32d2d
    classDef live fill:#e1f5ee,stroke:#1d9e75,color:#0f6e56
    classDef rule fill:#fff6e0,stroke:#d99a1f,color:#8a5f00
    class B1,E1,B2 gone
    class E2 live
    class R rule
Loading

Artifact bytes and the events naming them have always been removed together. This sweep is the first mechanism that removes bytes while the events referencing them live on, because the AgentRun ledger is append-only: a historical ModelCallAttempt keeps its captureArtifactId forever.

That is the review question — who reads a capture reference, and does it survive the referent being gone.

  • captureArtifactId in a conversation copy was a required mapping. Once a capture was reclaimed, branching or copying any Session holding a historical model call threw Conversation copy is missing Artifact … and could never succeed again. The key now leaves with the bytes; the attempt still copies and still decodes.
  • A child result's artifactIds is the same reference, in a second place. listTurnArtifacts filters on turn and status, never on source, so the typed subagent / agent_swarm results in any Session that ever spawned a child already carry capture ids. Three readers resolved them and failed on a miss — the Agent Graph reference validator, the copy's linked-child selection, and the copy's id rewrite — which meant reclaiming a capture left the Session unable to take a Side Conversation or a revision. All three now carry what is still there and drop the rest. This also fixes the same failure for an ordinary child Artifact the user deleted.
  • What still throws. A reference that crosses a Session or a lineage was never admissible and still fails — that boundary was never about survival. So does an archived tool result's Artifact: it holds that result's own bytes, and the two are removed together.
  • A conversation copy no longer carries retired captures over. A fork copied every Artifact of the source Session, these included — putting condemned bytes into a new Session, and putting them back after the sweep had seen an empty residue and stopped for good. Those copies were never reclaimed by anything. All three of the copy's selection passes (turn-scoped, linked child Session, explicit include list) now ask one predicate, so the property holds for the copy rather than for one of its passes.
  • Decoding is untouched. captureArtifactId stays in the ModelCallAttempt schema and its validator: hasExactShape fails a whole record on an unknown key, so dropping the field would strand exactly the records this exists to free the bytes of.

Also removes the two capture decoders #4631 left behind in conversation-copy.ts when it retired both provider-request event types from the emitted catalogue — a copy no longer reaches either.

🧹 The sweep

starts after openedArtifactStore.recover() — a write authority refuses every mutation until it has recovered
batch 256 Artifacts, fixed — the purge guard resolves the path of every record it is not deleting (~0.04 ms each), so a batch costs what the store costs, not what its own size costs. A smaller batch pays that same toll again for less work, so there is nothing to steer
pause max(250 ms, 3 × last batch) — a batch holds the writer lock and its cost rises with the store, so a fixed pause would not stay behind live turns
on failure retries after 1 s; gives up only after five consecutive failures
stops when nothing is left, or when the Runtime Host closes

Each batch is durable on its own, so stopping only means the next one does not start; a later run continues from the residue.

Giving up on the first failed batch is what the earlier revision did, and it was wrong in the way that costs the most: the failure that says the least about the next batch is exactly the one it quit on. A purge that fails part way leaves the write authority refusing every mutation until something recovers it — so a single bad batch stopped all reclamation for that user, permanently and silently. onError is now where the repair belongs, and the host recovers the store there; that same recovery is what hands the live turn's own writes back.

✅ Verification

  • format, lint clean. test:dist: @maka/storage 1,126, @maka/runtime 3,219, @maka/runtime-host 1,687 — 0 failures. typecheck also on @maka/desktop, @maka/ui, @maka/mcp, @maka/eval, maka-agent.
  • The sweep tests now drive a real writer end to end rather than a fake, which is what would have caught the give-up-on-first-failure bug: wired ahead of recovery, every batch is refused.
  • The copy test fails without the fix — verified by putting rewriteOwnedArtifactId back: Error: Conversation copy is missing Artifact artifact-gone.
  • Not run: full-repo suite, Playwright E2E. Nothing user-visible changes; the reclaimed Artifacts have had no reader since refactor: replace Headless with minimal Eval kernel #2605.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — traced the reference points, wrote the implementation and tests. Reviewed and verified by me.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 4, 2026
@Astro-Han
Astro-Han force-pushed the feat/reclaim-retired-provider-request-captures branch 3 times, most recently from d8b0213 to 2e08ddc Compare September 4, 2026 09:40
@Astro-Han
Astro-Han marked this pull request as ready for review September 4, 2026 09:40
@Astro-Han
Astro-Han requested review from ARE404, M4n5ter and likun666661 and removed request for ARE404 and M4n5ter September 4, 2026 10:12
Astro-Han added a commit that referenced this pull request Sep 4, 2026
Every model call stored a copy of the conversation. The prepared provider
request was serialized whole into a private Artifact, and the record beside it
carried up to 256 per-segment rows -- so one call cost tens of KB of database
plus a file that grew with the Session it belonged to. Nothing read either: the
capture's reader shipped in #1277 and was deleted in #2605, which kept the
producer, and the per-segment detail's only consumer folded it into four byte
totals.

Both producers are gone. The fold now runs where the request is dispatched, and
its result -- four byte totals plus a capped tool list, 1,971 B flat -- lands on
the canonical ModelCallAttempt, which already owns the request's facts. Per
model call with 60 tools and a 200-message conversation: database rows 46,077 to
1,971 B, capture file 136,967 B to none.

The Artifact store also sealed one snapshot per Session on every load and every
mutation, and a snapshot's revision hashes every record in its Session -- so 400
Sessions were sorted and hashed to answer a question about one. Every reader
reloads the whole store from the database first, so a kept snapshot never
survived to be read. Sealing one when a reader asks for it deletes the map, the
two methods that maintained it, and the per-mutation bookkeeping: one listPage
at 6,000 records goes 13.45 to 11.54 ms.

Compatibility: every decoder stays. `hasExactShape` fails a whole record on an
unknown key, so removing `captureArtifactId`, the `provider_request_capture`
source, or `PreparedRequestObservation` and its validator would strand exactly
the records this stops producing more of -- including their usage and cost.
Captures already written are left on disk; #4738 reclaims them.

One model-visible change: a sub-agent's spawn tool result listed the private
capture in `artifactIds` / `artifactCount`. A child turn now stores nothing of
its own, so that list is empty.

Also gives `graceful Host shutdown stops and drains an active Turn` the
checkpoint its sibling test already used, so the state its drain finds is not
left to how fast the machine is.

Closes #4082
Refs #4037
Refs #4704

Generated-by: Claude Code
Removing the capture sink stops the growth but leaves the residue, and the
residue is not the user's to clear: captures are `userVisible: false`, so no
UI lists them, and the only thing that ever deleted one was purging its
whole conversation. One workspace measured here holds 772 MB of them.

The store made them, so the store disposes of them. `purgeRetiredCaptures`
takes a bounded batch through the same mutation queue and purge-intent file
as every other deletion, and reports what is left; the sweep started at host
composition drains the rest behind live turns and stops when there is none.
A store that never held captures does one empty pass.

Interrupting it is safe by construction rather than by a checkpoint: each
batch is durable on its own and the next pass reads whatever remains, so a
crash, a close, or a stop all resume the same way.

`purge` now shares its body with the sweep instead of restating it.

Refs apache#4037

Generated-by: Claude Code
(cherry picked from commit 89628f2)
`captureArtifactId` was a required mapping, so once the sweep reclaimed a
capture Artifact, branching or copying any Session holding a historical
model call threw and could never succeed again. Every other Artifact
reference may keep throwing: the bytes and the events naming them have
always been removed together. This one cannot, because the sweep removes
bytes an append-only ledger still names — so the key now leaves with them
and the attempt keeps its record without the join.

Also drops the two capture decoders left behind by apache#4631, which retired
both provider-request event types from the emitted catalogue: a copy no
longer reaches either one.

Refs apache#4037

Generated-by: Claude Code
A write authority refuses every mutation until it has recovered, and the
sweep gives up after one failure. Starting it at composition meant its
first batch always landed before recovery ran, so it reclaimed nothing and
never retried. Both new tests recovered first and missed it.

Refs apache#4037

Generated-by: Claude Code
(cherry picked from commit d25a8ed)
A batch holds the writer lock and its cost rises with everything the store
holds, so a fixed 250 ms pause would not stay behind live turns on a store
big enough for the sweep to matter. The pause is now at least three times
the batch it follows.

Also drops a sweep assertion that could not fail: the batch size is a module
constant, so asserting it is a positive integer pinned nothing.

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the feat/reclaim-retired-provider-request-captures branch from 2e08ddc to 70fe70b Compare September 4, 2026 10:30
…tures

A fork copied every Artifact of the source Session, retired provider-request
captures included. That put condemned bytes back into a new Session -- and
because the sweep stops for good once it sees an empty residue, anything
copied after it finished was never reclaimed at all, which is the whole point
of retiring them. It also let the copy and the sweep race over the same
record.

The copy now leaves that source out, which is also the only source a reader
never asks for.

Generated-by: Claude Code
The sweep gave up on its first failed batch and logged it. Wired ahead of the
store's recovery, every batch was refused, so it reclaimed nothing at all for
anyone -- and the failure that says the least about the next batch is exactly
the one it quit on: a purge that fails part way leaves the write authority
refusing every mutation until something recovers it.

It now retries, and gives up only after five consecutive failures. `onError`
became the place that repairs what made the batch fail, which is why the host
recovers the store there -- that recovery is also what hands the live turn's
own writes back, not just this sweep's.

Generated-by: Claude Code
@likun666661

Copy link
Copy Markdown
Member

I think the core causal spine is sound: identify the retired provider_request_capture records, run them through the existing durable purge path in bounded background batches, and make the append-only attempt ledger tolerate the missing referent.

However, on the current head (70fe70b), several claims in the PR body do not appear to be implemented:

  1. Conversation copies can still recreate retired captures. copyConversationArtifacts() still selects source-session records by turn/exclusion only; it does not filter out source === 'provider_request_capture'. If the sweep reaches zero and stops, a later copy of an old Session can copy those captures into the target Session. They will remain until a later Runtime Host start. Either exclude this source in the copy path and add a regression test, or describe the guarantee as eventual cleanup on a later restart.

  2. The sweep still gives up after the first failure. startRetiredCaptureSweep() calls onError and immediately returns, and the test explicitly asserts one call. I do not see the documented 1-second retry, five-consecutive-failure limit, or store recovery in the error path. Because a failed purge invalidates writer state, recovery is also relevant to foreground writes, not only reclamation.

  3. The documented batch behavior differs from the code. The body says 16-256 Artifacts steered toward ~100 ms; the current implementation uses a fixed batch of 256. GitHub also reports 4 commits on the PR while the body says 6, so these may simply be missing commits.

From an Occam perspective, I would define the minimum complete requirement as:

Delete exactly the obsolete capture Artifacts already on disk, without breaking the retained ledger/copy path, blocking live writes, or allowing copies to recreate the retired population after the sweep has stopped.

The source filter, durable purge path, bounded pacing, post-recovery start, droppable captureArtifactId join, copy exclusion, and failure recovery/retry all protect a necessary link or boundary in that causal chain. The exact batch constants and old decoder cleanup are implementation/detail cleanup rather than part of the problem definition.

Could you either push the missing implementation described in the body or align the body and guarantees with the current head?

@Astro-Han

Copy link
Copy Markdown
Contributor Author

Sorry — you were reading the right code; I pushed the fixes to the wrong remote, so the head stayed at 70fe70b while the body described work you couldn't see. Head is now 82ff4a487d (6 commits).

  1. Copy exclusion — done, with a regression test. Your framing of the consequence is sharper than mine: since the sweep exits for good at remaining === 0, copies made after it finished were never reclaimed by anything, not just deferred to the next restart.
  2. Failure handling — retries after 1 s, gives up after five consecutive failures, and onError now calls openedArtifactStore.recover(). You're right that this matters beyond reclamation: that recovery is what hands the live turn's own writes back. The sweep tests now drive a real writer, which is what would have caught the original bug.
  3. Batch pacingnextSweepBatch() steers 16–256 toward a ~100 ms batch, as the body says. The commit-count mismatch was the same wrong-remote problem.

Happy to split the old decoder cleanup out if you'd rather keep this to the minimum.

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at exact head 82ff4a487d9aae0f7296d62c6c3efb9b36f18aa1. Five independent reviews ran on this, each sealing before reading the others. Four of them cleared the deletion itself. One found a reachable gap that I am raising as [P1], so I am not approving this head — everything else here is sound, and the fix looks small.

[P1] The retired-capture filter guards one of three selection paths

copyConversationArtifacts selects records in three passes. Only the first excludes the source, and its comment states the property the whole design depends on:

// A retired capture is on its way off disk and nothing reads one.
// Copying it would hand the new Session bytes already condemned,
// and would put records back after the sweep finished and stopped
// looking. Leaving them out also keeps the two from racing: the
// sweep can no longer delete a record this copy is holding.
record.source !== RETIRED_CAPTURE_ARTIFACT_SOURCE,

The next two passes do not look at source at all:

  • linkedArtifacts (artifact-store.ts:440-451) matches on sessionId, id and status !== 'deleted', then pushes the record.
  • includedArtifactIds (:453-463) matches on sessionId and the include/exclude sets.

This is reachable in production, not hypothetical. Side Conversation copy builds a linkedArtifacts request — kind === 'side_conversation' && linkedReferences.references.size > 0 — and one reviewer's probe confirmed by measurement that both unfiltered paths copy a capture into the target with source: provider_request_capture intact.

The consequences follow directly from the comment above:

  1. Condemned bytes land in a new Session. The copy hands the target rows that are on their way off disk.
  2. They are re-created after the sweep has finished. The sweep stops when nothing is left; a copy afterwards puts fresh provider_request_capture rows back, and the sweep is no longer looking.
  3. A later sweep reclaims them, hollowing out the snapshot's rewritten artifactIds.
  4. The race the comment says is prevented is not prevented on these paths. linkedArtifacts throws Linked Artifact <id> could not be copied when the record is gone — so a sweep that removes a capture between selection and copy turns a Side Conversation into a hard failure rather than a dropped key.

The PR summary's claim — "A conversation copy no longer carries retired captures over … which also stops it racing the sweep over the same record" — holds for the turn-scoped pass and not for the other two.

Smallest fix: apply the same source !== RETIRED_CAPTURE_ARTIFACT_SOURCE exclusion to all three selection passes. A regression should drive a real parent+child state through the Side Conversation coordinator, sweep, and then a real revision — asserting that ordinary child artifacts still copy.

What five reviews cleared

The deletion scope is correct. Exact-source deletion measured at 6/6 including tombstones and copy-rebuilt rows; all 14 remaining rows across the other 13 ArtifactSource values plus source-less rows survive; non-capture hardlink inodes and bytes stay readable.

Nothing else reads a capture reference. Enumerated independently by three seats, converging: the only production readers of captureArtifactId are the Core validator and conversation-copy.ts. Diagnostics and the ledger never dereference it.

Keeping captureArtifactId in the schema is load-bearing, not conservative. hasExactShape rejects any unknown key outright — measured directly: a record carrying the field validates while allowed contains it, and the same record fails the moment the key is removed from allowed. Dropping the field would strand exactly the records this change exists to free the bytes of.

The sweep's failure handling is sound. Five consecutive failures stop the loop in this process only — there is no persistent cursor, and the next Runtime Host start resumes over what remains. A successful batch resets the counter. stop() prevents the next batch rather than interrupting the current one, and an interrupted batch leaves a purge intent that recovery replays.

Two defensive observations that do not block, from the cross-team seat:

  • Closure does not wait for an in-flight sweep. stop() sets a flag and close proceeds to storage.close; if closure lands between file deletion and the metadata write, the batch can delete its targets and fail the metadata write, leaving a purge intent that the next recovery replays. The target set is still limited to retired captures. Worth having stop await the in-flight batch, or folding it into a unified drain.
  • On a large store each batch still resolves removal entries across every non-batch record as a path guard, so the 16–256 adaptive batch cannot shorten lock hold time — shrinking the batch does not help. That is live-turn latency rather than wrong deletion.

(The reference-filter finding above was reached independently by two seats and, from the opposite direction, by a third that had classified it as unreachable in production until the Side Conversation caller was identified.)

Everything except the filter gap is in good shape, and the premise — that these bytes are duplicates nothing reads — is well supported.

简体中文

82ff4a487d9aae0f7296d62c6c3efb9b36f18aa1 上评审。五次独立评审在这个 head 上进行,各自封存后才互看。其中四次确认删除本身是正确的;一次找到了一个生产可达的缺口,我按 [P1] 提出,因此这个 head 我不批准——除此之外都是扎实的,而且修法看起来很小。

[P1] retired-capture 过滤只守住了三条选择路径中的一条

copyConversationArtifacts 分三段选记录。只有第一段排除了这个 source,而它的注释恰好道出了整个设计所依赖的性质:

// 一条退役 capture 正在离开磁盘,而且没有人读它。
// 复制它等于把已被判定要删的字节交给新 Session,
// 并且会在 sweep 结束、不再查看之后把记录放回去。
// 把它们排除在外也让两者不再竞争:
// sweep 不能再删掉这次复制正持有的记录。
record.source !== RETIRED_CAPTURE_ARTIFACT_SOURCE,

而后两段根本不看 source:

  • linkedArtifacts(artifact-store.ts:440-451)只匹配 sessionIdidstatus !== 'deleted',然后就把记录推入。
  • includedArtifactIds(:453-463)只匹配 sessionId 和 include/exclude 集合。

这在生产中是可达的,不是假设。 Side Conversation 的复制会构造 linkedArtifacts 请求——kind === 'side_conversation' && linkedReferences.references.size > 0——而一位评审的探针实测确认:这两条未过滤的路径都会把 capture 复制进目标,source: provider_request_capture 原样保留

后果直接来自上面那段注释:

  1. 被判定要删的字节进入了新 Session。 复制把正在离开磁盘的行交给了目标。
  2. 它们在 sweep 结束之后被重新造出来。 sweep 在没有剩余时停止;之后的一次复制会放回新的 provider_request_capture 行,而 sweep 已经不再查看了。
  3. 之后的 sweep 会回收它们,把 snapshot 中被重写过的 artifactIds 挖空。
  4. 注释声称被阻止的那个竞争,在这两条路径上并没有被阻止。 当记录已不存在时,linkedArtifacts 会抛出 Linked Artifact <id> could not be copied——所以一次在「选择」与「复制」之间移除了 capture 的 sweep,会把一次 Side Conversation 变成硬失败,而不是丢掉一个 key。

PR 摘要里那句——「conversation copy 不再把退役 capture 带过去……这也阻止了它与 sweep 争抢同一条记录」——对 turn 那一段成立,对另外两段不成立。

最小修法: 把同一个 source !== RETIRED_CAPTURE_ARTIFACT_SOURCE 排除应用到全部三段选择上。回归测试应当驱动一个真实的 parent+child 状态,经过 Side Conversation coordinator、sweep,再走一次真实 revision——并断言普通的 child artifacts 仍然会被复制

五次评审确认没问题的部分

删除范围是正确的。 精确 source 删除实测 6/6(含 tombstone 与两条 copy 重建行);其余 13 个 ArtifactSource 加上无 source 的共 14 行全部保留;非 capture 的 hardlink inode 与字节仍可读。

没有别的地方读 capture 引用。 三个席位独立穷举并收敛:captureArtifactId 的生产读者只有 Core 校验器和 conversation-copy.ts,diagnostics 与 ledger 从不解引用它。

captureArtifactId 留在 schema 里是承重的,不是保守。 hasExactShape 对任何未知 key 一律否决——直接实测过:一条带该字段的记录在 allowed 含它时通过校验,而同一条记录在该 key 被移出 allowed 的瞬间失败。删掉这个字段,会让正是这次改动要释放其字节的那批记录全部失效。

sweep 的失败处理是稳的。 连续五次失败只停止本进程内的循环——没有持久游标,下一次 Runtime Host 启动会从剩余部分继续。成功一批会重置计数。stop() 阻止的是下一批而不是打断当前批,而被打断的一批会留下 purge intent 供 recovery 重放。

两条不阻塞的防御性观察,来自跨团队席位:

  • 关闭路径不等待进行中的 sweep。 stop() 只置标志,close 继续走 storage.close;若关闭恰好落在文件删除与 metadata 写入之间,该批次可能删完目标却写 metadata 失败,留下一个 purge intent 由下次 recovery 重放。目标集合仍只限于退役 capture。建议让 stop 等待在飞批次,或纳入统一的 drain。
  • 大库上每批仍会把「非本批的每一条记录」都解析一遍作为路径守卫,所以 16–256 的自适应批次缩不短锁持有时间——把批次调小并没有帮助。这是活跃 turn 的延迟问题,不是删错数据。

(上面那条引用过滤的发现由两个席位独立得出;第三个席位从相反方向得到同一处,并在 Side Conversation 调用方被指认之前一直将其归类为「生产不可达」。)

除了这个过滤缺口,其余都处于良好状态;而这次改动的前提——这些字节是无人读取的重复数据——是有充分支撑的。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my previous comment: the finding above is real, but I graded it P1 on a production-reachability claim I had not established. It should be [P2]. Same head, 82ff4a487d9aae0f7296d62c6c3efb9b36f18aa1.

What still holds

The filter inconsistency is real and reads directly from the code. copyConversationArtifacts selects in three passes; only the turn-scoped one excludes RETIRED_CAPTURE_ARTIFACT_SOURCE. linkedArtifacts (artifact-store.ts:440-451) and includedArtifactIds (:453-463) do not look at source.

Feeding a capture id to the explicit include path does copy it, with source intact — measured. So the defensive gap is not theoretical: any caller that reaches these passes with a capture id re-creates a condemned row, and a later sweep reclaims it.

What I got wrong

I wrote that Side Conversation makes this reachable in production, on the basis that its copy builds a linkedArtifacts request. That the request is built is true. That a capture id can travel in it is what I did not verify, and it does not follow.

linkedArtifacts is populated from collectConversationCopyLinkedChildReferences, which walks tool results and collects only typed linked-child references — content.kind === 'subagent' (content.artifactIds) and content.kind === 'agent_swarm'. Those are child-run outputs a tool declared. A provider_request_capture is written by the runtime per provider request; it is not a tool result artifact, so nothing puts it in that set. The graph reference validator then checks session, status and turn lineage — no source check, but also no path I can show that admits a capture in the first place.

The same applies to the include path in production: it is fed from collectConversationCopySessionFileRefs, which filters to session_file refs. Reproducing the include copy required standing up an advanced owner client and disguising a capture as a typed session_file — a constructed precondition, not a production route.

So the honest statement is: the three passes disagree about source, and that is worth fixing before some future caller does reach them — but I cannot show a current production path that does. That is a defensive gap, P2, which is where the first reviewer to enumerate this had it before I raised it. I moved it up too fast, on a code-shaped inference rather than a demonstrated route.

This does not change the fix, which is still to apply the exclusion to all three passes — cheap, and it closes the gap regardless of whether anything reaches it today. It does change the urgency, and it means this head has no P0 or P1 from my side. Everything I listed as cleared in the previous comment stands unchanged, including the measured deletion scope, the single pair of captureArtifactId readers, and the load-bearing schema field.

If someone can demonstrate a real subagent or agent_swarm tool result whose declared artifactIds include a capture — historical data would be the place to look — that would restore the reachability argument and the higher grade with it.

简体中文

更正我上一条评论:上面那个发现是真实的,但我把它定成 P1 所依据的「生产可达」这一点,我并没有真正建立。它应该是 [P2]。 同一个 head,82ff4a487d9aae0f7296d62c6c3efb9b36f18aa1

仍然成立的部分

过滤不一致是真实的,而且直接读代码就能看到。 copyConversationArtifacts 分三段选择,只有 turn-scoped 那一段排除了 RETIRED_CAPTURE_ARTIFACT_SOURCE;linkedArtifacts(artifact-store.ts:440-451)与 includedArtifactIds(:453-463)都不看 source

把一个 capture id 喂给显式 include 路径,确实会把它复制过去,且 source 原样保留——这是实测的。所以这个防御缺口不是空谈:任何以 capture id 到达这两段的调用方,都会重新制造出一条已被判定要删的行,而之后的 sweep 会回收它。

我错在哪

我写了「Side Conversation 让这条路径在生产中可达」,依据是它的复制会构造 linkedArtifacts 请求。「请求会被构造」是真的;「一个 capture id 能装在里面走」才是我没有验证的那一步,而且它并不能由前者推出。

linkedArtifacts 的内容来自 collectConversationCopyLinkedChildReferences,它遍历的是工具结果,只收集有类型的 linked-child 引用——content.kind === 'subagent'(取 content.artifactIds)和 content.kind === 'agent_swarm'那些是某个工具声明出来的子运行产物。provider_request_capture 是运行时按每次 provider 请求写下的,它不是工具结果产物,所以没有任何东西会把它放进那个集合。 随后的 graph 引用校验会检查 session、status 和 turn lineage——确实没有 source 检查,但我也拿不出一条一开始就能让 capture 进来的路径。

生产中的 include 路径同理:它由 collectConversationCopySessionFileRefs 供给,而后者只筛 session_file 引用。复现那次 include 复制,需要先架起一个 advanced owner client、并把一个 capture 伪装成有类型的 session_file——那是构造出来的前置条件,不是生产路线。

所以诚实的说法是:这三段对 source 的处理不一致,值得在将来某个调用方真的到达它们之前修掉——但我拿不出当前存在的生产路径。 那是一个防御性缺口,P2,也正是最先穷举出这一处的那位评审在我抬高它之前所给的等级。我升得太快了,依据的是一个形似代码的推断,而不是一条被演示出来的路径。

这不改变修法——仍然是把那条排除应用到全部三段,成本很低,而且无论今天是否有东西能到达它,都能关掉这个缺口。但它改变了紧迫性,也意味着从我这边看,这个 head 没有 P0/P1。 我在上一条评论里列为「已确认无问题」的部分全部维持不变,包括实测过的删除范围、captureArtifactId 仅有的那一对读者,以及那个承重的 schema 字段。

如果有人能拿出一个真实的 subagentagent_swarm 工具结果,其声明的 artifactIds 里含有 capture——历史数据是该找的地方——那可达性论证就能恢复,等级也随之恢复。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

A conversation copy names records three ways -- by turn, by a linked child
Session, and by an explicit include list -- and only the turn-scoped pass
excluded the captures on their way off disk. The property the exclusion exists
for belongs to the copy, not to one of its passes, so it now lives in one
predicate all three ask.

Naming a capture outright still fails the copy rather than silently dropping
it: a caller asking for a linked Artifact that is being reclaimed is asking for
something that will not be there.

Generated-by: Claude Code
The sweep steered its batch size toward a batch costing ~100 ms, which it can
never reach and never should have tried: the purge guard resolves the path of
every record it is NOT deleting, so a batch costs what the store costs. Asking
for fewer records pays that same toll again for less work, which is the
opposite of what steering down was for -- the constant's own comment already
said the cost is the store's, not the batch's.

A fixed batch and the pause that scales with what the last one measured. That
pause is the lever that was doing the work all along.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Fixed at 17b36a21ff.

[P1] filter on one of three passes — real, and fixed on all three. The exclusion now lives in one isRetiredCapture predicate that the turn-scoped pass, the linked-child pass and the include-list pass all ask, so the property belongs to the copy rather than to one of its passes. A capture named outright still fails the copy (Linked Artifact <id> could not be copied) rather than being silently dropped: a caller asking for an Artifact that is being reclaimed is asking for something that will not be there. Regression covers both previously-unguarded paths, and asserts ordinary linked child artifacts still copy — it fails on the old code.

One correction on reachability, which changes the severity but not the fix: I could not find a production caller that can put a capture id into either set. linkedArtifacts comes from collectConversationCopyLinkedChildReferences, which walks tool-result content only, and includeArtifactIds from collectConversationCopySessionFileRefs, which walks session_file StorageRefs — neither ever reads a ModelCallAttempt, and captureArtifactId is the only place a capture id appears. A probe that hands the store a capture id directly shows the store copies it, which is what the fix addresses; it does not show a caller that does. So I'd have filed this as a P2 defence-in-depth gap rather than a reachable P1 — the guarantee should hold for the whole function regardless, so it is fixed either way. If a caller does exist, please name it and I'll correct the record.

Batch pacing — your second non-blocking observation is the more interesting one, and it retired the adaptive batch entirely. If the guard resolves every non-batch record's path, a batch costs what the store costs, so steering the size down pays that fixed toll again for fewer records — strictly worse than not steering. The constant's own comment already said this and I kept the mechanism anyway. Now a fixed 256, with the duty-cycle pause doing the work it was always doing.

Closure not draining an in-flight batch — leaving as is for this PR. The worst case you describe is a purge intent that the next recovery replays, over a target set that is only retired captures; making stop awaitable means changing what the host's close array means for every closer in it, which is a bigger change than this PR should carry. Worth its own issue if you'd like one filed.

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retracting my previous correction. The original [P1] stands, and I have verified the reachability chain myself this time. I also think the fix on 17b36a21 is incomplete in a way that matters — details below.

Where my correction was wrong

I wrote that linkedArtifacts is populated only from typed linked-child references, so a provider_request_capture — written by the runtime, not produced by a tool — could never travel in one. The first half is true. The second does not follow, and I asserted it without checking what actually fills those artifactIds.

A child result's artifactIds is not a list of tool outputs. It is every artifact on the child's turn:

  • listTurnArtifacts filters on turnId and status !== 'deleted' only — no source filter (artifact-store.ts:729-731).
  • listArtifactsForTurn passes it straight through (execution-composition.ts:1065-1066).
  • Three production paths write the whole list into the typed result: artifactIds: artifacts.map((artifact) => artifact.id) at session-manager.ts:2923, :3260 and :3349 — none filters by source.

And a child's captures are exactly artifacts on that turn. A Host end-to-end test deleted by #4722 shows the consequence directly: after a real child run, listTurnArtifacts returns exactly one record, asserted to be source: 'provider_request_capture', and the parent's decoded subagent result is asserted to carry that same id list. A second case asserts a child's capture count equals its provider-request count and that the typed result carries all of them.

From there the rest already held: conversation-copy.ts:772-803 projects those ids into linked-child references, and session-revision-graph-references.ts:244-255 resolves each with getInSession, failing operation_unavailable if one is gone.

So this is not a future caller. Ledgers written by any Session that ever spawned a subagent or swarm already contain capture ids inside typed child results. I had the shape of the argument and stopped one step short of the data.

The fix closes the copy, but the historical ids are still live

17b36a21 applies isRetiredCapture to all three selection passes, and the extraction is the right shape — the predicate's comment names the real invariant, that the property belongs to the copy rather than to one of its passes. The tests cover ordinary linked artifacts still copying, the include path skipping a capture, and the linked path rejecting one.

But that third behaviour meets the historical data head-on. linkedArtifacts now excludes captures from its find, so the lookup misses and throws Linked Artifact <id> could not be copied. Given a typed subagent result that already lists a capture id, this means:

  • before the sweep, the copy now throws where it previously succeeded;
  • after the sweep, getInSession in the graph-reference validator fails first with operation_unavailable.

Either way a Side Conversation or revision over a Session with a historical subagent result fails rather than degrades — and the throw is newly introduced by this fix, on data that already exists on disk.

Rejecting a caller that names a capture outright is defensible. Rejecting a caller that named it because the runtime put it there is a different case, and it is the common one. Worth considering: drop condemned ids from a linked-child request rather than failing it — the capture carries nothing a copy needs, which is the same reasoning that lets capturedArtifactJoin drop the key instead of throwing.

One seat is currently verifying exactly this — whether a swept Session with a historical capture id can still take a revision or Side Conversation on this head. I will report that result either way; if it passes, this concern is void and only the grading correction stands.

Grading

Back to [P1], on reachability that is now demonstrated rather than inferred: production writes capture ids into typed child results today, and both the pre-fix copy and the post-fix rejection are reachable from ordinary data. Everything else in my first comment — the measured deletion scope, the single pair of captureArtifactId readers, the load-bearing schema field, the sweep's failure handling — is unchanged and still clear.

Apologies for the churn. The second correction is on me: I inferred a boundary from the shape of the code instead of following the data that crosses it, which is the same mistake in the opposite direction.

简体中文

撤回我上一条更正。原来的 [P1] 成立,而且这次可达性链条是我自己核过的。 另外我认为 17b36a21 上的修复在一个要紧的地方是不完整的——详见下文。

我的更正错在哪

我写道:linkedArtifacts 只由有类型的 linked-child 引用填充,所以一个 provider_request_capture——由运行时写入、并非工具产出——不可能装在里面走。前半句是真的。后半句并不由它推出,而我没有去查究竟是什么填满了那些 artifactIds 就断言了它。

child result 的 artifactIds 不是工具输出列表,而是那个 child turn 上的全部 artifact:

  • listTurnArtifacts turnIdstatus !== 'deleted' 过滤——没有 source 过滤(artifact-store.ts:729-731);
  • listArtifactsForTurn 原样透传(execution-composition.ts:1065-1066);
  • 三条生产路径把整份列表写进 typed result:artifactIds: artifacts.map((artifact) => artifact.id),见 session-manager.ts:2923:3260:3349——没有一条按 source 过滤。

而 child 的 captures 正好就是那个 turn 上的 artifact。一条被 #4722 删除的 Host 端到端测试直接展示了后果:一次真实 child 运行之后,listTurnArtifacts 返回恰好一条记录,并断言它的 sourceprovider_request_capture;随后断言父运行解码出的 subagent 结果携带的正是同一份 id 列表。另一组用例断言 child 的 capture 数量等于它的 provider 请求数量,而 typed result 携带了全部这些 capture。

再往后的环节本来就成立:conversation-copy.ts:772-803 把这些 id 投影成 linked-child 引用,session-revision-graph-references.ts:244-255 逐个用 getInSession 解析,少一个就以 operation_unavailable 失败。

所以这不是「未来的调用方」。任何曾经派生过 subagent 或 swarm 的 Session,其账本里已经在 typed child result 内部含有 capture id。 我把论证的形状拿对了,却在数据面前停了最后一步。

修复关上了复制,但历史 id 仍然是活的

17b36a21isRetiredCapture 应用到了全部三段选择上,而且抽取的形状是对的——谓词的注释点出了真正的不变量:这个性质属于「复制」本身,而不属于它三段选择中的某一段。 测试覆盖了普通 linked artifact 仍会复制、include 路径跳过 capture、以及 linked 路径拒绝 capture。

但第三种行为正面撞上了历史数据。 linkedArtifacts 现在把 capture 排除在 find 之外,于是查找落空并抛出 Linked Artifact <id> could not be copied给定一个已经列有 capture id 的 typed subagent 结果,这意味着:

  • sweep 之前,这次复制会在原本成功的地方抛错;
  • sweep 之后,graph 引用校验里的 getInSession 会更早地以 operation_unavailable 失败。

无论哪种,对一个带有历史 subagent 结果的 Session 做 Side Conversation 或 revision,都会「失败」而不是「降级」——而其中那个抛错,是这次修复在磁盘上已存在的数据上新引入的。

拒绝一个径直点名 capture 的调用方,是站得住的。但拒绝一个「之所以点名是因为运行时把它放进去了」的调用方,是另一回事,而且那才是常见情形。 值得考虑:把已被判定要删的 id 从 linked-child 请求里剔除,而不是让请求失败——capture 并不携带复制所需要的任何东西,这正是 capturedArtifactJoin 选择丢掉 key 而不是抛错的同一条理由。

目前有一席正在验证的就是这一点——在这个 head 上,一个被清扫过、且带有历史 capture id 的 Session 是否还能完成 revision 或 Side Conversation。无论结果如何我都会报;如果它通过了,这条顾虑作废,只保留等级更正。

定级

回到 [P1],依据是现在已被演示而非推断的可达性:生产今天就在把 capture id 写进 typed child result,而修复前的复制与修复后的拒绝,都能从普通数据到达。我第一条评论里其余部分——实测的删除范围、captureArtifactId 仅有的那一对读者、承重的 schema 字段、sweep 的失败处理——维持不变,依然清楚。

为反复改动致歉。第二次更正的责任在我:我从代码的形状推断出了一条边界,而没有跟着穿过它的数据走一遍——这是同一个错误的反方向。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 17b36a21ff2edd8db59abefa582a6cf4692663a2. The three-pass filter is correctly closed, but [P1] is not — the fix moves the failure earlier rather than removing it. This is the verification I said I would report either way, and it came back negative.

Both timings fail, measured

Rebuilt Storage on this head and ran a probe through the real SQLite store, the production typed-result collector, and the production graph validator, with a typed agent_swarm result whose artifactIds is [capture, tool-result] — the shape production writes today:

Result
Graph validation, before the sweep passes
Side Conversation, before the sweep throws Linked Artifact child-capture could not be copied
Ordinary linked tool-result still copies
A capture named via includeArtifactIds correctly skipped
Graph validation, after a real sweep operation_unavailable: Retained Agent Graph Artifact is unavailable
Same result with only the capture id removed passes immediately

The last row isolates the cause: the capture id is the only thing standing between these Sessions and a working copy.

Before this fix, a Side Conversation over that data copied. On this head it throws before the sweep, and the unrecoverable failure after the sweep is unchanged. The new rejection is introduced by the fix, on data already on disk.

The new test locks in a premise the history disproves

artifact-store.test.ts:395-405 makes "naming a capture in linkedArtifacts must fail" a contract. That is right for a caller that names one deliberately — but it holds only if a capture cannot end up in a typed child result, and it can.

SessionManager.finalizeAndListChildTurnArtifacts() still applies no source filter, and all three completion/recovery paths still write the whole list (session-manager.ts:2923, :3260, :3349). The Host end-to-end tests removed by #4722 show the outcome on real runs: a local-read child's turn has exactly one artifact, asserted to be a capture, and the parent's typed subagent result is asserted to carry that same id list; the implementation-child case asserts every provider request's capture reaches the typed result alongside the tool-result and writeback. So the caller is not naming a capture — the runtime put it there.

What the fix needs

Two parts, because filtering at copy selection is too late:

  1. Project confirmed retired-capture ids out of the linked-child request before validation treats them as required references. By the time copyConversationArtifacts selects, the graph validator has already resolved each id.
  2. Preserve or rewrite retired-source identity before deletion — after the sweep the source metadata is gone, so nothing downstream can tell "a capture that was reclaimed" from "a tool-result that is genuinely missing". Alternatively, hold back captures still named by a typed result. Degrading every unknown missing artifact is not an option — that is the distinction the current design depends on everywhere else.

Confirmed closed, and one observation to keep

The copy-selection gap itself is properly fixed: isRetiredCapture (artifact-stores.ts:103-106) is applied at all three passes and at purge. Ordinary linked artifacts still copy, which the new tests assert. The adaptive batch became a fixed 256, and the comment now states plainly why a smaller batch cannot shorten a live turn's wait — the fixed cost is per batch, so shrinking it only makes the residue pay the same toll more times. That is a good response to the earlier scheduling note.

stop() still does not wait for the in-flight batch (artifact-stores.ts:301-303 returns a flag setter; the loop reads it only after the current purge and pause). Unchanged from the previous head and still worth folding into a unified drain.

Verification on this head: Storage build; Artifact store 50/50; Runtime Host graph refs 14/14; the new probe reproduces both failures; merge tree against current main clean; hosted test and windows_recovery both green. The green CI is accurate — nothing existing tests cover is broken. The failure needs a Session with a historical subagent or swarm result, which no test constructs.

简体中文

17b36a21ff2edd8db59abefa582a6cf4692663a2 上复审。三段过滤确实关闭了,但 [P1] 没有——这次修复把失败提前了,而不是消除了它。 这是我先前说过「无论结果如何都会报」的那次验证,结果是否定的。

两个时序都失败,是实测的

在这个 head 上重建 Storage 后,用真实 SQLite store + 生产 typed-result collector + 生产 graph validator 跑探针,输入是一个 artifactIds[capture, tool-result] 的 typed agent_swarm 结果——这正是生产今天写出的形状:

结果
sweep 前的 graph validation 通过
sweep 前的 Side Conversation Linked Artifact child-capture could not be copied
普通 linked tool-result 仍然复制
includeArtifactIds 点名的 capture 正确跳过
真实 sweep 之后的 graph validation operation_unavailable: Retained Agent Graph Artifact is unavailable
同一结果,仅去掉 capture id 立即通过

最后一行把原因隔离出来了:capture id 是这些 Session 与一次可用复制之间唯一的障碍。

在这次修复之前,针对这类数据的 Side Conversation 是可以复制的。在这个 head 上,它在 sweep 之前就抛错,而 sweep 之后那个不可恢复的失败依然存在。 那个新的拒绝是修复在磁盘已有数据上引入的

新增测试锁死了一个被历史证伪的前提

artifact-store.test.ts:395-405 把「在 linkedArtifacts 中点名 capture 就必须失败」定成了合同。对一个刻意点名的调用方,这是对的——但它只在「capture 不可能出现在 typed child result 里」时成立,而它可以。

SessionManager.finalizeAndListChildTurnArtifacts() 仍然没有 source 过滤,三条 completion/recovery 路径仍然整包写入(session-manager.ts:2923:3260:3349)。被 #4722 删除的那些 Host 端到端测试展示了真实运行的结果:一个 local-read child 的 turn 只有一条 artifact,被断言为 capture,而父运行的 typed subagent 结果被断言携带同一份 id 列表;implementation child 那组则断言每个 provider 请求的 capture 都与 tool-result、writeback 一起进入了 typed result。所以不是调用方点名了 capture——是运行时把它放进去的。

修复需要做什么

两部分,因为在 copy selection 处过滤太晚了:

  1. 在 validation 把这些 id 当作必需引用之前,就把已确认为 retired capture 的 id 从 linked-child 请求中投影掉。 等到 copyConversationArtifacts 开始选择时,graph validator 早已逐个解析过它们了。
  2. 在删除之前保留或改写 retired-source 身份——sweep 之后 source 元数据就没了,下游再也分不清「一个被回收的 capture」和「一个真正缺失的 tool-result」。或者,暂不删除那些仍被 typed result 点名的 capture。把所有未知的缺失 artifact 一概降级不是选项——那正是当前设计在其他每一处都依赖的区分。

已确认关闭的部分,以及一条需要保留的观察

copy-selection 缺口本身修得是对的:isRetiredCapture(artifact-stores.ts:103-106)在三段选择与 purge 处都被应用。普通 linked artifact 仍然会复制,新测试对此有断言。自适应批次改成了固定 256,而且注释现在直白地说明了为什么更小的批次缩不短活跃 turn 的等待——固定成本是按批次算的,缩小它只会让残余多交几次同样的过路费。这是对先前那条调度意见的良好回应。

stop() 仍然不等待在飞的批次(artifact-stores.ts:301-303 返回的是一个置标志的函数;循环只在当前 purge 与暂停之后才读它)。与上一个 head 相同,仍值得纳入统一的 drain。

这个 head 上的验证:Storage 构建通过;Artifact store 50/50;Runtime Host graph refs 14/14;新探针复现了上述两个失败;与当前 main 的合并树干净;hosted testwindows_recovery 均为绿。CI 绿是准确的——现有测试覆盖的东西没有一处被破坏。这个失败需要一个带有历史 subagent 或 swarm 结果的 Session,而没有任何测试构造它。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

This branch already established the rule -- a reference to a capture must
survive its target being reclaimed, because the ledger naming it is
append-only -- and then applied it to exactly one field, `captureArtifactId`.

The same ids live in a second place. A child result carries `artifactIds`,
and that list is every Artifact the child's turn held: `listTurnArtifacts`
filters on turn and status, never on source, so a Session that ever spawned a
subagent has capture ids inside a typed tool result today. Three readers
resolved them and failed on a miss: the Agent Graph reference validator, the
copy's linked-child selection, and the copy's id rewrite. Reclaiming a capture
therefore made a Session unable to take a Side Conversation or a revision --
the very Sessions this branch exists to reclaim bytes from.

All three now carry what is still there and drop the rest. The boundary they
still enforce is the one that was never about survival: a reference that
crosses a Session or a lineage was never admissible, and still fails. An
archived tool result's Artifact keeps throwing too -- it holds that result's
own bytes, and the two are removed together.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed at 9b73fa9d81. You're right, and the root cause is one step further back than either of us put it.

I verified the chain independently: listTurnArtifacts filters on turn and status only (artifact-store.ts:729), finalizeAndListChildTurnArtifacts passes it through, and all three call sites write the whole list into the typed result. The decisive evidence is in #4722's own diff — the assertion it deleted read childArtifacts.length === 1 / source === 'provider_request_capture', followed by deepEqual(typedSpawnResult.artifactIds, childArtifacts.map(a => a.id)). So a child run that produced nothing still reported one artifact to its parent, and that artifact was a capture.

The root cause: this branch established the rule and then applied it to one field. The PR body already says a capture reference must tolerate a missing referent — that is the whole diagram in the description. I applied it to captureArtifactId and stopped, because that field has "capture" in its name. artifactIds is the same reference in a generic list, and it reaches three readers that all failed on a miss:

  1. prepareAgentGraphRevisionReferencesgetInSessionoperation_unavailable
  2. copyConversationArtifacts linked pass → Linked Artifact … could not be copied
  3. rewriteArtifactIds / rewriteSnapshotArtifactIdsrequiredMappedIdConversation copy is missing Artifact …

You named 1 and 2. 3 would have failed even with both of those fixed, so the per-site patch you suggested — and the throw I added last round — were both treating symptoms. All three now carry what is still there and drop the rest, sharing one named concept with capturedArtifactJoin.

Two things I deliberately did not relax:

  • A reference crossing a Session or a lineage still fails. That boundary was never about survival, and it is the one the validator exists for.
  • An archived tool result's Artifact still throws on a miss. It holds that result's own bytes; the two are removed together, so a copy that lost it has lost something a reader will ask for.

Side effect worth noting: this also fixes the same failure for an ordinary child Artifact a user deleted, which is pre-existing and has nothing to do with captures. Same rule, same cause.

I considered the alternative — keep the rows as tombstones so a reader can tell "reclaimed" from "lost" — and rejected it. In an append-only ledger with reclaimable bytes, reference completeness is not a maintainable invariant; keeping 87% of the Artifact rows alive to defend it would just turn reclamation into a permanent false positive, and raise the purge guard's whole-table cost forever.

Not in this PR: artifactIds should never have contained captures in the first place — that is a write-side classification bug in listTurnArtifacts's callers. Captures are no longer produced so there is no new data, and I'll file it separately.

format, lint clean; @maka/storage 1119, @maka/runtime 3206, @maka/runtime-host 1676, 0 failures. Two tests that encoded the old contract were rewritten rather than deleted, and the graph-reference policy table lost its deleted Artifact / missing Artifact rejection rows to a new acceptance test.

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at exact head 9b73fa9d81d604ec6db1c174a929d46a8700ad94. The P1 is closed — both failures I reported are gone, measured on the same probe that found them.

Verified closed

Same real SQLite store, same production typed agent_swarm result:

Before Now
Side Conversation, before the sweep threw Linked Artifact ... could not be copied succeeds — capture skipped, ordinary tool-result kept
Graph validation, after a real purge operation_unavailable ok: true
Side Conversation after that purge succeeds, ordinary Artifacts preserved

Focused 90/90, three packages build, both exact-head checks green, merge tree clean.

The fix is better than what was asked for

I suggested projecting condemned ids out of the linked-child request. What landed is the more general and more correct statement: a child result names every Artifact its turn held, in a ledger that can never be rewritten, so an id in it outlives whatever it named. What the validator checks is therefore whether a reference reaches outside its own child and lineage — not whether its target survived.

That reframing dissolves the problem I could not see a way around. I had thought the fix would need to preserve retired-source identity before deletion, because after the sweep nothing downstream can distinguish "a capture that was reclaimed" from "a tool-result that is genuinely missing". Under this rule the distinction is not needed: neither one is the reader's problem, and both resolve to nothing. linkedArtifacts moving from if (!record) throw to if (record) selected.push(...) follows from the same sentence.

Agent Graph revision references outlive the Artifacts they name covers it, and the existing reject invalid ownership boundaries is untouched.

One comment to narrow

The new comment says a reference "that crosses a Session or a lineage was never admissible and still fails". The lineage half holds; the Session half does not, and cannot.

getInSession(childSessionId, artifactId) resolves against that Session's own snapshot, so a foreign-Session id is not found and returns record: null — indistinguishable from a missing one. It therefore takes the new continue, and the record.sessionId !== childSessionId branch below is unreachable for that case.

Harmless in practice: the downstream copy resolves against the child Session too, so foreign data cannot be copied in, and an ordinary typed result never produces such an id. Not worth grading. But the comment currently claims a guard that the lookup shape removes, and a future reader may rely on it. Narrowing it to lineage would make it accurate.

One observation carried forward

stop() still does not wait for the in-flight batch — it sets a flag (artifact-stores.ts:301-303) and the loop reads it only after the current purge and pause, while close proceeds to storage.close. Unchanged across all three heads. An interrupted batch leaves a purge intent that recovery replays, so this is not a correctness hole; it is still worth folding into a unified drain.

Everything cleared in the earlier rounds stands: the measured deletion scope, captureArtifactId's two production readers, the load-bearing schema field, FIFO ordering and purge-intent recovery, and five-failure stop being per-process rather than permanent. The adaptive batch became a fixed 256 with a comment that states plainly why a smaller batch cannot shorten a live turn's wait.

This is a feature, so the merge decision remains a human's.

简体中文

9b73fa9d81d604ec6db1c174a929d46a8700ad94 上批准。P1 已关闭——我报告的两个失败都消失了,而且是用发现它们的同一个探针实测的。

已验证关闭

同一个真实 SQLite store,同一个生产 typed agent_swarm 结果:

之前 现在
sweep 前的 Side Conversation Linked Artifact ... could not be copied 成功——capture 被跳过,普通 tool-result 保留
真实 purge 之后的 graph validation operation_unavailable ok: true
那次 purge 之后的 Side Conversation 成功,普通 Artifact 保留

focused 90/90,三个包构建通过,exact-head 两项检查绿,合并树干净。

这个修复比被要求的更好

我建议的是「把已判定要删的 id 从 linked-child 请求里投影掉」。而实际落地的是一个更一般、也更正确的表述:child result 点名了它那个 turn 持有的每一个 Artifact,记录它的账本永远无法改写,所以其中的 id 会活得比它所指的东西更久。因此校验器要检查的是引用有没有伸出自己的 child 与 lineage 之外,而不是它的目标是否还活着

这个重新表述,消解了我原本看不到出路的那个问题。 我原以为修复必须在删除前保留 retired-source 身份,因为 sweep 之后下游再也分不清「一个被回收的 capture」和「一个真正缺失的 tool-result」。而在这条规则下,这个区分不再需要:两者都不是读者的问题,都解析为无。 linkedArtifactsif (!record) throw 变成 if (record) selected.push(...),是同一句话的推论。

新增测试 Agent Graph revision references outlive the Artifacts they name 覆盖了它,既有的 reject invalid ownership boundaries 未被触动。

有一处注释应当收窄

新注释说,一个*「跨越了 Session 或 lineage 的引用,本来就不可接纳,现在仍然失败」*。lineage 那一半成立;Session 那一半不成立,而且不可能成立。

getInSession(childSessionId, artifactId) 是在那个 Session 自己的快照上解析的,所以一个外部 Session 的 id 根本找不到,返回 record: null——与「缺失」无从区分。 于是它会走新加的 continue,而下面那条 record.sessionId !== childSessionId 分支对这种情况不可达

实践上无害:下游的复制同样按 child Session 解析,所以外部数据无法被复制进来,而正常的 typed result 也不会产生这种 id。不值得定级。 但这条注释目前声称了一道「查找形状本身已经取消掉」的守卫,而未来的读者可能会依赖它。把它收窄到 lineage 就准确了。

一条继续保留的观察

stop() 仍然不等待在飞的批次——它只置一个标志(artifact-stores.ts:301-303),而循环只在当前 purge 与暂停之后才读它,与此同时 close 会继续走向 storage.close。三个 head 一路未变。被打断的批次会留下一个 purge intent 供 recovery 重放,所以这不是正确性缺口;但仍值得纳入统一的 drain。

前几轮确认过的内容全部维持:实测的删除范围、captureArtifactId 的两个生产读者、承重的 schema 字段、FIFO 顺序与 purge-intent 恢复,以及「五次失败」只是进程内停止而非永久放弃。自适应批次已改为固定 256,并附有一段直白说明「为什么更小的批次缩不短活跃 turn 的等待」的注释。

这是一个 feature,合并与否仍由人决定。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

@Astro-Han
Astro-Han merged commit 22715e8 into apache:main Sep 4, 2026
2 checks passed
@Astro-Han
Astro-Han deleted the feat/reclaim-retired-provider-request-captures branch September 4, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants